<?php

/**
 * mForge Framework
 *
 * LICENSE:
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * The license is bundled with this package in the file LICENSE.txt.
 * You may also obtain a copy of the License at this URL:
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * @copyright  Copyright (c) 2011, Jonathan Hedrén
 * @author     Jonathan Hedrén <jonathan.hedren@gmail.com>
 * @license    http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0
 */


namespace mForge;

require_once 'IRoutingResult.php';
require_once 'IDependent.php';
require_once 'IModule.php';
require_once 'AttributeParser.php';
require_once 'DecorationAttributes.php';
require_once 'FilterAttributes.php';
require_once 'RedirectResult.php';
require_once 'Request.php';
require_once 'Response.php';
require_once 'RoutingContext.php';
require_once 'Translator.php';
require_once 'Utility.php';
require_once 'ValidationAttributes.php';
require_once 'ValidationResult.php';
require_once 'ViewResult.php';
require_once 'helpers/AbstractHelper.php';
require_once 'helpers/HtmlBinder.php';
require_once 'helpers/Url.php';

/**
 * The Application class is responsible for handling application instantiation,
 * routing, events, initialization of modules and dependency injection.
 */
class Application
{
    const ON_ACTION_RESULT_EXECUTING = 'on action result executing';
    const ON_DISPATCH = 'on dispatch';
    const ON_DISPATCH_ERROR = 'on dispatch error';
    const ON_ROUTE_MATCH = 'on route match';
    const ON_NO_ROUTE_MATCH = 'on no route match';

    /**
     *
     * @var Application
     */
    private static $instance;

    /**
     *
     * @var string
     */
    private $appName;

    /**
     *
     * @var string
     */
    private $appPath;

    /**
     *
     * @var string
     */
    private $basePath;

    /**
     *
     * @var array
     */
    private $config;

    /**
     *
     * @var array
     */
    private $eventCallbacks = array();

    /**
     *
     * @var array
     */
    private $modules = array();

    /**
     *
     * @var Request
     */
    private $request;

    /**
     *
     * @var Response
     */
    private $response;

    /**
     *
     * @var RoutingContext
     */
    private $routingContext;

    /**
     *
     * @var array
     */
    private $routes = array(
	'any' => array(),
        'get' => array(),
        'post' => array()
    );

    /**
     * Application bootstrap. Sets up paths and configuration, initializes
     * modules and dispatches the request.
     *
     * @param string $basePath
     */
    private function  __construct($basePath, $appName)
    {
        $appPath = $basePath . DIRECTORY_SEPARATOR . 'apps' . DIRECTORY_SEPARATOR . $appName;

        if (!is_dir($appPath)) {
            throw new \Exception("Can not find the app '$appName' @ '$appPath'");
        }

        $this->appName = $appName;
        $this->appPath = $appPath;
        $this->basePath = $basePath;
        $this->request = new Request();
        $this->response = new Response();

        $config = $this->getConfig();

        date_default_timezone_set($config['timezone']);

        $this->initModules();
        $this->initHelpers();

        try {
            $this->dispatchEvent(self::ON_DISPATCH);

            $this->dispatch(array('' => $config['defaultRoute']));
        } catch (\Exception $e) {
            if ($config['throwExceptions']) {
                throw $e;
            }
            $this->dispatchEvent(self::ON_DISPATCH_ERROR, $e);
        }
    }

    /**
     * Iniitalizes any helpers.
     */
    private function initHelpers()
    {
        // Get helper names from the config
        $config = $this->getConfig();
        foreach ($config['helpers'] as $helperName) {

            // We need a fully qualified class name
            $helperClass = '\\mForge\\Helpers\\' . ucfirst($helperName);
            if (!class_exists($helperClass)) {
                require_once $this->basePath . \DIRECTORY_SEPARATOR
                             . 'helpers' . \DIRECTORY_SEPARATOR
                             . $helperName . '.php';
            }

            // Check/inject dependencies
            $this->injectStaticDependencies($helperClass);

            // Initialize the helper;
            if (method_exists($helperClass, 'init')) {
                $helperClass::init($this->request, $this->response, $this);
            }
        }
    }

    /**
     *
     * @param IDependent $object
     * @param string $objectDesciption
     * @return void
     */
    private function injectDependencies($object, $objectDesciption)
    {
        if (!$object instanceof IDependent) {
            return;
        }

        foreach ($object->getDependencies() as $dependency) {
            if (!isset($this->modules[$dependency])) {
                throw new \Exception("Please ensure that the '$dependency' module is loaded before '$objectDesciption' as the latter depends on it.");
            }

            $dependencySetter = 'set' . $dependency . 'Module';
            if (method_exists($object, $dependencySetter)) {
                $object->$dependencySetter($this->modules[$dependency]);
            }
        }
    }

    /**
     *
     * @param string $class
     * @return void
     */
    private function injectStaticDependencies($class)
    {
        $reflectionClass = new \ReflectionClass($class);
        if (!$reflectionClass->implementsInterface(__NAMESPACE__ . '\\IDependent')) {
            return;
        }

        foreach ($class::getDependencies() as $dependency) {
            if (!isset($this->modules[$dependency])) {
                throw new \Exception("Please ensure that the '$dependency' module is loaded before '$class' as the latter depends on it.");
            }

            $dependencySetter = 'set' . $dependency . 'Module';
            if (method_exists($class, $dependencySetter)) {
                $class::$dependencySetter($this->modules[$dependency]);
            }
        }
    }


    /**
     *
     */
    private function initModules()
    {
        $appConfig = $this->getConfig();
        foreach ($appConfig['modules'] as $moduleName => $moduleSettings) {
            if (!is_array($moduleSettings)) {
                $moduleName = $moduleSettings;
                $moduleSettings = array();
            }

            // Include module config file
            $moduleConfigFile = $this->basePath . \DIRECTORY_SEPARATOR
                              . 'modules' . \DIRECTORY_SEPARATOR
                              . $moduleName . \DIRECTORY_SEPARATOR
                              . 'etc/config.php';
            if (is_file($moduleConfigFile)) {
                require_once $moduleConfigFile;
            }

            if (isset($config)) {
                // Extend the 'original' module settings (i.e. $config) with
                // $settings loaded from the app config.
                $moduleSettings = array_merge($config, $moduleSettings);
            }

            // Include the module file and create a new module instance.
            $moduleClass = '\\mForge\\Modules\\' . ucfirst($moduleName) . '\\Module';

            // This check is only here to avoid problems if class files are
            // concatinated.
            if (!class_exists($moduleClass)) {
                require_once $this->basePath . \DIRECTORY_SEPARATOR
                             . 'modules' . \DIRECTORY_SEPARATOR
                             . $moduleName . \DIRECTORY_SEPARATOR . 'Module.php';
            }
            $module = new $moduleClass();

            // Check/inject dependencies
            $this->injectDependencies($module, $moduleName . ' module');

            // Initialize the module;
            $module->init($this->request, $this->response, $this, $moduleSettings);

            // Setting the moduleAlias setting allows for the module to act as
            // another module. Any dependencies to 'moduleAlias' will get this
            // module instead.
            if (isset($moduleSettings['moduleAlias']) && !empty($moduleSettings['moduleAlias'])) {
                $this->modules[$moduleSettings['moduleAlias']] = $module;
            } else {
                $this->modules[$moduleName] = $module;
            }
        }
    }

    /**
     * Executes any callback closures associated with the event, passing any
     * extra arguments to the callback. The callbacks will be executed in the
     * priority order.
     *
     * @param string $event
     * @param mixed $args
     * @return Application
     */
    public function dispatchEvent($event)
    {
        $args = func_get_args();
        array_shift($args);

        if (isset($this->eventCallbacks[$event])) {
            foreach ($this->eventCallbacks[$event] as $callback) {
                call_user_func_array($callback, $args);
            }
        }
        return $this;
    }

    /**
     * Binds a callback closure/function to one or more routes (passed either as
     * a string for a single route or an array for multiple routes). 
     * If the $httpMethod parameter is omitted the callback will be executed
     * regardless of the request HTTP method.
     *
     * @param string|array $route
     * @param string|closure $httpMethod
     * @param closure $callback
     */
    public function route($route, $httpMethod, $callback = null)
    {
        if ($callback === null) {
            $callback = $httpMethod;
            $httpMethod = 'any';
        }

        // The $callback must be a closure
        if (!is_callable($callback) && (!is_array($callback) || count($callback) != 2)) {
            throw new \Exception('The supplied callback must be a function/closure or an array with the object to act upon as the first argument and the name of the method to invoke as the second argument.');
        }

        $httpMethod = strtolower($httpMethod);

        if (!isset($this->routes[$httpMethod])) {
            $this->routes[$httpMethod] = array();
        }

        if (is_array($route)) {
            foreach ($route as $r) {
                $this->routes[$httpMethod][$r] = $callback;
            }
        } else {
            $this->routes[$httpMethod][$route] = $callback;
        }
    }


    /**
     * Dispatches the request.
     *
     * @param array $rewrites
     * @param array $routingContext
     */
    private function dispatch(array $rewrites)
    {
        $path = $this->request->getPathInfo();

        foreach ($rewrites as $from => $to) {
            if ($path == trim($from, '/')) {
                $path = $to;
                break;
            }
        }

        $httpMethod = strtolower($this->request->getMethod());

        $routesMap = $this->routes['any'];
        if (isset($this->routes[$httpMethod])) {
            $routesMap = $this->routes[$httpMethod] + $routesMap;
        }

        foreach ($routesMap as $route => $callback) {
            $routeTokens = explode('/', trim($route, '/'));
            $pathTokens = explode('/', $path);

            if (count($routeTokens) !== count($pathTokens)) {
                continue;
            }

            $routeParams = array();
            
            //Try to match the route tokens with the path tokens
            foreach ($routeTokens as $i => $routeToken) {
                // Set any route params
                if (strpos($routeToken, '@') === 0) {
                    $routeParams[substr($routeToken, 1)] = $pathTokens[$i];
                } else if ($routeToken != $pathTokens[$i]) {
                    // There's a mismatch - continue to the next route
                    continue 2;
                }
            }

            if (is_array($callback)) {
                $reflectionObject = new \ReflectionObject($callback[0]);
                $callbackReflection = $reflectionObject->getMethod($callback[1]);
                $namespaceName = $reflectionObject->getNamespaceName();
            } else {
                $callbackReflection = new \ReflectionFunction($callback);
                $namespaceName = $callbackReflection->getNamespaceName();
            }

            // Find out which module that the callback belongs by examining the
            // namespace name. The third segment of the namespace is the module
            // name.
            $namespaceNameParts = explode('\\', $namespaceName);
            $moduleName = $namespaceNameParts[2];

            $this->routingContext = new RoutingContext(
                $route,
                $routeTokens,
                $routeParams,
                $moduleName,
                $this->modules[$moduleName]
            );

            $this->dispatchEvent(self::ON_ROUTE_MATCH, $this->routingContext);

            $this->invokeRouteCallback($callback, $callbackReflection);

            return;
        }

        $this->dispatchEvent(self::ON_NO_ROUTE_MATCH, $this);
    }

    /**
     * Returns the name of the application (equals to the name of the folder
     * where the app resides).
     *
     * @return string
     */
    public function getAppName()
    {
        return $this->appName;
    }

    /**
     * Returns the path of the app, i.e. [base path]/apps/[app name].
     *
     * @return string
     */
    public function getAppPath()
    {
        return $this->appPath;
    }

    /**
     * Returns the base path of mForge, i.e. the parent folder of /apps,
     * /modules, etc.
     *
     * @return string
     */
    public function getBasePath()
    {
        return $this->basePath;
    }

    /**
     * Returns the app configuration array. When any module is loaded, the
     * module configuration will be merged into the app configuration.
     *
     * @return array
     */
    public function getConfig()
    {
        if ($this->config === null) {
            $configFile = realpath($this->appPath . DIRECTORY_SEPARATOR . 'etc' . DIRECTORY_SEPARATOR . 'config.php');
            if (!is_file($configFile)) {
                throw new \Exception('Missing config.php - cannot proceed.');
            }

            require_once $configFile;

            if (!isset($config)) {
                throw new \Exception('Missing configuration - cannot proceed.');
            }
            $this->config = $config;
        }
        return $this->config;
    }

    /**
     * Returns the instance of $moduleName or null if no such module is loaded,
     *
     * @param string $moduleName
     * @return IModule
     */
    public function getModule($moduleName)
    {
        return isset($this->modules[$moduleName]) ? $this->modules[$moduleName] : null;
    }

    /**
     * Returns the routing context. The routing context will only be set if a
     * route is matched.
     *
     * @return RoutingContext
     */
    public function getRoutingContext()
    {
        return $this->routingContext;
    }

    /**
     * Invokes the route callback. Will try to match any route callback
     * arguments with request parameters and pass any matches to the route
     * callback.
     *
     * @param array|closure $callback
     * @param \ReflectionFunctionAbstract $callbackReflection
     */
    private function invokeRouteCallback($callback, \ReflectionFunctionAbstract $callbackReflection)
    {
        // Get reflections of any action method parameters
        $callbackArgsReflections = $callbackReflection->getParameters();

        $callbackArgs = array();

        $validationResult = null;

        $requestParams = $this->routingContext->getRouteParams() 
                       + $this->request->getPost()
                       + $this->request->getParams();

        // If any request parameter has a key matching the name of the route
        // callback parameter, add the request parameter value to the callback
        // arguments array.
        // If the reflection parameter is typecasted to a class, create a new
        // instance of that class and add the instance to the callback arguments
        // array.
        foreach ($callbackArgsReflections as $actionArg) {
            if (isset($requestParams[$actionArg->getName()])) {
                $callbackArgs[] = $requestParams[$actionArg->getName()];
            } else if ($actionArg->getClass() != null) {
                $argInstance = $actionArg->getClass()->newInstance();
                $callbackArgs[] = $argInstance;
                if ($argInstance instanceof ValidationResult) {
                    $validationResult = $argInstance;
                }
            } else {
                $callbackArgs[] = null;
            }
        }
        
        // If HTTP POST, bind post data to any callback method arguments.
        // The data will also be filtered and validated.
        if ((count($_GET) || count($_POST)) && count($callbackArgs)) {
           $this->bindRequestParameters($callbackArgs, $callbackArgsReflections, $validationResult);
        }

        // Execute the callback with the callback arguments.
        $routingResult = call_user_func_array($callback, $callbackArgs);

        // All callback results must implement the IRoutingResult interface
        if (!$routingResult instanceof IRoutingResult) {
            throw new \UnexpectedValueException('The route callback must return an instance of IRoutingResult.');
        }

        $this->dispatchEvent(self::ON_ACTION_RESULT_EXECUTING, $routingResult);

        // Execute the routing result returned from the route callback.
        $routingResult->executeResult($this->request, $this->response, $this);
    }

    /**
     * Binds request parameters (POST or GET) to the type-casted arguments of
     * the route callback.
     *
     * @param array $actionArgs
     * @param array $actionArgsReflections
     * @param ValidationResult $validationResult
     */
    private function bindRequestParameters(array $actionArgs, array $actionArgsReflections, $validationResult)
    {
        foreach ($actionArgs as $i => $parameter) {
            if (!is_object($parameter)) {
                continue;
            }

            $reflectionClass = $actionArgsReflections[$i]->getClass();
            if ($this->request->isPost()) {
                $requestDataToBind = $this->request->getPost($reflectionClass->getShortName(), array());
            } else {
                $requestDataToBind = $this->request->getParams($reflectionClass->getShortName(), array());
            }
			
			// Bind request data to matching route callback parameter properties
            foreach ($reflectionClass->getProperties(\ReflectionProperty::IS_PUBLIC) as $propertyReflection) {

                $property = $propertyReflection->getName();

                $value = isset($requestDataToBind[$property]) ? $requestDataToBind[$property] : null;

                // Get all attributes of the property matching the field var
                $attributes = AttributeParser::getPropertyAttributesFor($parameter, $property);

                // Assume the posted data is valid.
                $valid = true;
                $validationMessages = array();

                if ($attributes != null) {
                    // Apply filters and validate values.
                    foreach ($attributes as $attribute) {
                        if ($attribute instanceof IFilterAttribute) {
                            $value = $attribute->filter($value);
                        } else if ($attribute instanceof IValidationAttribute) {
                            $result = $attribute->isValid($value);
                            if (!$result) {
                                $validationMessages[] = $attribute->getErrorMessage();
                            }

                            if ($valid) {
                                $valid = $result;
                            }
                        }
                    }
                }

                // Set validation results
                $validationResult->setFieldResult($property, $valid, $validationMessages);

                // Finally set the filtered and validated value to the parameter
                // object.
                $parameter->$property = $value;
            }
        }
    }

    /**
     * Observes an event by passing the name of the event and a closure/callback
     * function which will be executed when the event is dispatched. The
     * optional $priority argument can be set to be sure that the passed
     * callback will be executed before or after any callbacks with lower/higher
     * priority.
     *
     * @param string $event
     * @param array|closure $callback
     * @param integer $priority
     * @return Application
     */
    public function observeEvent($event, $callback, $priority = 0)
    {
        if (!is_callable($callback) && (!is_array($callback) || count($callback) != 2)) {
            throw new \Exception('The callback must be a closure or an array with the object to act upon as the first argument and the name of the method to invoke as the second argument.');
        }

        if (!isset($this->eventCallbacks[$event])) {
            $this->eventCallbacks[$event] = new \SplPriorityQueue();
        }

        $this->eventCallbacks[$event]->insert($callback, (int)$priority);

        return $this;
    }

    /**
     * Runs the application by creating a new instance of it.
     *
     * @param string $basePath
     * @param string $appName
     */
    public static function run($basePath, $appName = 'Default')
    {
        error_reporting(-1);

        if (self::$instance != null) {
            throw new \RuntimeException('mForge is already running!');
        }
        self::$instance = new self($basePath, $appName);
    }
}